/* eslint-disable jquery/no-hide */
/* eslint-disable jquery/no-ajax */
/* eslint-disable jquery/no-show */
import {
	enable_spellcheck,
	disable_spellcheck,
	suggest_word_for_misspelled,
	load_dictionary,
} from '../libs/spellcheck_ace.js';

import { Node } from './node';
import { Workspace } from './workspace';
import { Input } from './input';
import { Settings } from './settings';
import { UI } from './ui';
import { data } from './data';
import { Utils } from './utils';
import { RichTextFormatter } from './richTextFormatter';

// TODO: refactoring proposals
//
// Create Platform class: provides platform specific info and abstractions
// Create History class: handles command history navigation (undo/redo)
// Create Editor class: handles editor setup and events

export var App = function(name, version) {
	const self = this;
	this.utils = Utils;

	this.setTheme = function(name, e) {
		let themeName = e ? e.target.value : name;
		setTimeout(self.initGrid, 50);
		setTimeout(self.workspace.updateArrows, 50);
		$('#theme-stylesheet').attr(
			'href',
			Utils.getPublicPath(`themes/${themeName}.css`)
		);
	};

	this.setLanguage = function(language, e) {
		const languageId = e ? e.target.value : language;
		//load_dictionary(self.settings.language().split('-')[0]);
		const event = new CustomEvent('yarnSetLanguage');
		event.language = languageId;
		window.dispatchEvent(event);
		window.parent.dispatchEvent(event);
	};

	this.setDocumentType = function(documentType, e) {
		const documentTypeId = e ? e.target.value : documentType;
		self.settings.documentType(documentTypeId);

		app.updateNodeLinks();

		const event = new CustomEvent('yarnSetDocumentType');
		event.language = documentTypeId;
		window.dispatchEvent(event);
		window.parent.dispatchEvent(event);
	};

	this.setMarkupLanguage = function(language, e) {
		const markupLanguage = e ? e.target.value : language;
		app.settings.markupLanguage(markupLanguage);
		self.richTextFormatter = new RichTextFormatter(self);
		self.mustRefreshNodes.notifySubscribers();
	};

	this.setFiletypeVersion = function(typeVersion, e) {
		const filetypeVersion = e ? e.target.value : typeVersion;
		self.filetypeVersion = filetypeVersion;
	};

	// Ideally this dependencies should be injected by index.js
	this.input = new Input(self);
	this.settings = new Settings(self);
	this.workspace = new Workspace(self);
	this.ui = new UI(self);
	this.richTextFormatter = new RichTextFormatter(self);

	this.data = data;
	this.name = ko.observable(name);
	this.version = ko.observable(version);
	this.editing = ko.observable(null);
	this.nodes = ko.observableArray([]);
	this.tags = ko.observableArray([]);
	this.mustRefreshNodes = ko.observable();
	this.mustUpdateTags = true;
	this.nodeHistory = [];
	this.nodeFuture = [];
	this.editingHistory = [];
	this.editingSaveHistoryTimeout = null;
	this.focusedNodeIdx = -1;
	this.editor = null;
	this.nodeVisitHistory = [];
	this.plugins = { pluginStorage: {} };
	this.clipboard = '';
	this.nodeClipboard = [];
	this.speachInstance = null;
	this.editingPath = ko.observable(null);
	this.$searchField = $('.search-field');
	this.isEditorInPreviewMode = false;
	this.isEditorInPlayMode = ko.observable(false);
	this.isEditorSplit = false;
	this.isEditorFocused = false;
	this.editorResizeHandleOptions = {
		handleSelector: '#editor-resize-handle',
		resizeHeight: false,
		resizeWidthFrom: 'right',
		onDragStart: function() {
			self.isSplitEditorInFocus = true;
			$('#node-editor').removeClass('split-editor-out-of-focus');
		},
		onDragEnd: function() {
			self.editor.resize();
			self.settings.editorSplitSize($('#editor-form').width());
		},
	};

	// inEditor
	//
	// Indicates if we are in the editor view
	this.inEditor = () =>
		(self.editing() || (self.isEditorSplit && self.isEditorFocused)) &&
		!self.ui.isDialogOpen();

	// inWorkspace
	//
	// Indicates if we are in the workspace view
	this.inWorkspace = () =>
		(!self.editing() ||
			(self.isEditorSplit && self.isEditorFocused === false)) &&
		!self.ui.isDialogOpen();

	// run
	//
	// Initializes the application
	this.run = function() {
		var osName = 'Unknown OS';
		if (navigator.platform.indexOf('Win') != -1) osName = 'Windows';
		if (navigator.platform.indexOf('Mac') != -1) osName = 'MacOS';
		if (navigator.platform.indexOf('X11') != -1) osName = 'UNIX';
		if (navigator.platform.indexOf('Linux') != -1) osName = 'Linux';
		if (navigator.platform.indexOf('Linux') != -1) osName = 'Linux';

		var userAgent = navigator.userAgent || navigator.vendor || window.opera;
		if (
			/android|iPad|iPhone|iPod/.test(userAgent.toLowerCase()) &&
			!window.MSStream
		) {
			osName = 'mobile';
		}

		if (osName == 'Windows') self.workspace.zoomSpeed = 0.1;

		/*
		window.addEventListener('beforeunload', e => {
			this.data.saveAppStateToLocalStorage();
			return null;
		});
		*/
		window.addEventListener('DOMContentLoaded', e => {
			this.data.loadAppStateFromLocalStorage();

			// PWA is receiving shared data
			const parsedUrl = new URL(window.location);
			const sharedText =
				parsedUrl.searchParams.get('text') || parsedUrl.searchParams.get('url');
			if (sharedText !== null) {
				self.insertTextAtCursor(sharedText + '\n', true);
				// setTimeout(() => self.insertTextAtCursor(sharedText), 100);
			}
		});

		$('#app').show();
		ko.applyBindings(self, $('#app')[0]);

		self.settings.apply();

		// search field enter
		$('.search-title input').click(self.updateSearch);
		$('.search-body input').click(self.updateSearch);
		$('.search-tags input').click(self.updateSearch);
		self.$searchField.on('input', self.updateSearch);
		self.$searchField.on('keyup', function(e) {
			// escape
			if (e.keyCode == 27) self.clearSearch();
			else self.searchWarp();
		});

		if (osName === 'mobile') self.workspace.setZoom(3);

		$(window).on('resize', function() {
			if (self.ui.isScreenNarrow() && self.editing() && self.isEditorSplit) {
				self.toggleEditorView();
			}

			self.workspace.updateArrows();
			self.initGrid();
		});
		setTimeout(self.initGrid, 50);

		this.guessPopUpHelper = function() {
			if (/color=#([a-zA-Z0-9]{3,6})$/.test(self.getTagBeforeCursor())) {
				self.insertColorCode();
			}
		};

		// TODO: move to editor
		this.insertColorCode = function() {
			if ($('#colorPicker-container').is(':visible')) {
				return;
			}
			// http://bgrins.github.io/spectrum/
			$('#colorPicker').spectrum('set', self.editor.getSelectedText());
			$('#colorPicker').spectrum('toggle');
			$('#colorPicker-container').css({
				left: self.input.mouse.x - 70,
				top: self.input.mouse.y - 50,
			});
			$('#colorPicker-container').show();

			self.togglePreviewMode(true);
			setTimeout(() => {
				const currentColor = $('#colorPicker').spectrum('get');
				self.applyPickerColorEditor(currentColor);
			}, 100);
		};

		// TODO: move to editor
		this.applyPickerColorEditor = function(color) {
			const selectRange = JSON.parse(
				JSON.stringify(self.editor.selection.getRange())
			);
			self.editor.selection.setRange(selectRange);
			const colorCode = color.toHexString().replace('#', '');
			self.editor.session.replace(selectRange, colorCode);
			self.editor.selection.setRange({
				start: self.editor.getCursorPosition(),
				end: {
					row: self.editor.getCursorPosition().row,
					column: self.editor.getCursorPosition().column - colorCode.length,
				},
			});
			self.togglePreviewMode(true);
		};

		document.addEventListener(
			'contextmenu',
			function(evt) {
				if (self.editing()) evt.preventDefault();
			},
			false
		);

		// Handle file dropping
		document.ondragover = document.ondrop = e => {
			e.preventDefault();
		};
		document.body.ondrop = e => {
			e.preventDefault();
			for (var i = 0; i < e.dataTransfer.files.length; i++) {
				data.appendFile(
					e.dataTransfer.files[i],
					e.dataTransfer.files[i].name,
					false
				);
			}
		};

		// this is how the VSCode extension sends its messages back to the app
		window.addEventListener('message', event => {
			const message = event.data;

			switch (message.type) {
				// sent whenever the temporary file that's open gets changed
				case 'UpdateNode':
					// find the node that was being edited... we check originalNodeTitle here
					// since it's possible that the user changed the node's title in the editor
					self.nodes().forEach(node => {
						if (
							node.title().trim() === message.payload.originalNodeTitle.trim()
						) {
							node.title(message.payload.title);
							node.tags(message.payload.tags);
							node.body(message.payload.body);

							// re-send the document back to the extension so it updates its underlying text document
							self.setYarnDocumentIsDirty();
						}
					});
					break;
				default:
					break;
			}
		});

		// Callback for embedding in other webapps
		var event = new CustomEvent('yarnReady');
		event.document = document;
		event.data = data;
		event.app = this;
		window.dispatchEvent(event);
		window.parent.dispatchEvent(event);
	};

	this.limitNodesUpdate = function(fn) {
		self.nodes.extend({
			rateLimit: { method: 'notifyWhenChangesStop', timeout: 250 },
		});
		fn();
		self.nodes.limit(callback => () => callback());
	};

	this.getNodesConnectedTo = function(toNode) {
		var connectedNodes = [];
		var nodes = self.nodes();
		for (var i in nodes) {
			if (nodes[i] != toNode && nodes[i].isConnectedTo(toNode, true)) {
				var hasNode = false;
				for (var j in connectedNodes) {
					if (connectedNodes[j] == nodes[i]) {
						hasNode = true;
						break;
					}
				}
				if (!hasNode) connectedNodes.push(nodes[i]);
			}
		}
		return connectedNodes;
	};

	this.matchConnectedColorID = function(fromNode) {
		var nodes = self.getNodesConnectedTo(fromNode);
		for (var i in nodes) nodes[i].colorID(fromNode.colorID());
	};

	this.quit = function() {
	};

	this.canEditNodeMeta = function(title) {
		return true;
	};
	this.sanitiseNodeTitle = function() {
	};
	this.validateTitle = function() {
		var enteredValue = document.getElementById('editorTitle').value;
		var editorTitle = $('#editorTitle');
		if (
			self.getOtherNodeTitles().includes(enteredValue) ||
			self.titleExistsTwice(enteredValue)
		) {
			editorTitle.attr('class', 'title title-error');
			editorTitle.attr('title', 'Another node has the same title');
		} else if (
			!RegExp('^[a-zA-Z0-9_]+$', 'i').test(enteredValue)
		) {
			editorTitle.attr('class', 'title title-error');
			editorTitle.attr(
				'title',
				'Only upper or lower case letters and numbers are allowed in a node title.'
			);
		} else {
			editorTitle.removeAttr('title');
			editorTitle.removeClass('title-error');
		}
	};

	this.refreshWindowTitle = function() {
		let title = '';
		if (data.lastStorageHost() === 'LOCAL') {
			title =
				'Yarn - ' +
				(data.editingPath() || data.editingName()) +
				' ' +
				(data.isDocumentDirty() ? '*' : '');
		}
		document.title = title;
	};

	// returns `true` is we're in the VSCode extension
	this.usingVisualStudioCodeExtension = function() {
		// this is put on window by the extension
		return !!window.vsCodeApi;
	};

	// returns `true` if we're opening a file in the VSCode extension
	// (as opposed to running the full editor)
	this.editingVisualStudioCodeFile = function() {
		return window.editingVsCodeFile === true;
	};

	// This should be called whenever we want to mark the document as changed.
	this.setYarnDocumentIsDirty = function() {
		// If we're in the VSCode extension, send it an update
		if (
			self.usingVisualStudioCodeExtension() &&
			self.editingVisualStudioCodeFile()
		) {
			data.getSaveData(data.editingType()).then(payload => {
				window.vsCodeApi.postMessage({
					type: 'DocumentEdit',

					// we just send the whole doc here every time...
					payload,
				});
			});
		}
	};

	this.recordNodeAction = function(action, node) {
		//we can't go forward in 'time' when
		//new actions have been made
		if (self.nodeFuture.length > 0) {
			for (var i = 0; i < self.nodeFuture.length; i++) {
				var future = self.nodeFuture.pop();
				delete future.node;
			}

			delete self.nodeFuture;
			self.nodeFuture = [];
		}

		var historyItem = {
			action: action,
			node: node,
			lastX: node.x(),
			lastY: node.y(),
		};

		if (action == 'removed') {
			historyItem.lastY += 80;
		}

		self.nodeHistory.push(historyItem);

		self.setYarnDocumentIsDirty();
	};

	this.historyDirection = function(direction) {
		function removeNode(node) {
			var index = self.nodes.indexOf(node);
			if (index >= 0) {
				self.nodes.splice(index, 1);
			}
			self.updateNodeLinks();
		}

		var historyItem = null;

		if (direction == 'undo') historyItem = self.nodeHistory.pop();
		else historyItem = self.nodeFuture.pop();

		if (!historyItem) return;

		var action = historyItem.action;
		var node = historyItem.node;

		if (direction == 'undo') {
			//undo actions
			if (action == 'created') {
				historyItem.lastX = node.x();
				historyItem.lastY = node.y();
				removeNode(node);
			} else if (action == 'removed') {
				self.recreateNode(node, historyItem.lastX, historyItem.lastY);
			}

			self.nodeFuture.push(historyItem);
			self.setYarnDocumentIsDirty();
		} //redo undone actions
		else {
			if (action == 'created') {
				self.recreateNode(node, historyItem.lastX, historyItem.lastY);
			} else if (action == 'removed') {
				removeNode(node);
			}

			self.nodeHistory.push(historyItem);
			self.setYarnDocumentIsDirty();
		}
	};

	this.recreateNode = function(node, x, y) {
		self.nodes.push(node);
		node.moveTo(x, y);
		self.updateNodeLinks();
	};

	this.setSelectedColors = function(node) {
		var nodes = self.workspace.getSelectedNodes();
		nodes.splice(nodes.indexOf(node), 1);

		for (var i in nodes) nodes[i].colorID(node.colorID());
	};

	this.pasteNodes = function() {
		if (!self.nodeClipboard.length) {
			return;
		}

		self.workspace.deselectAll();

		self.nodeClipboard.forEach(function(copiedNode) {
			var node = new Node({
				title: self.getUniqueTitle(copiedNode.title()),
				body: copiedNode.body(),
				tags: copiedNode.tags(),
				colorID: copiedNode.colorID(),
				x: copiedNode.createX,
				y: copiedNode.createY,
			});

			self.nodes.push(node);
			self.workspace.selectNodes(node);
			self.recordNodeAction('created', node);
		});

		self.updateNodeLinks();
	};

	this.confirmDeleteNodes = function(toDelete) {
		const node = Array.isArray(toDelete) ? undefined : toDelete;
		const selected = Array.isArray(toDelete)
			? [...toDelete]
			: node && node.selected
			? [...self.workspace.getSelectedNodes()]
			: [toDelete];

		if (selected.length) {
			Swal.fire({
				title: 'Are you sure?',
				text: `${selected.length} ${
					selected.length === 1 ? 'node' : 'nodes'
				} will be deleted.`,
				icon: 'warning',
				showCancelButton: true,
				confirmButtonText: 'Yes, delete!',
				cancelButtonText: 'No, cancel!',
				reverseButtons: true,
			}).then(result => {
				if (result.value) {
					self.deleteNodes(selected);
				}
			});
		}
	};

	this.deleteNodes = function(nodes) {
		const list = Array.isArray(nodes) ? nodes : [nodes];
		const promises = [];

		for (let i = list.length - 1; i >= 0; --i) promises.push(list[i].remove());

		Promise.all(promises).then(() => {
			self.limitNodesUpdate(() => {
				for (let i = list.length - 1; i >= 0; --i) {
					if (self.inEditor()) {
						if (self.editing() === list[i]) {
							self.closeEditor();
						}
					}
					self.deleteNode(list[i]);
				}

				self.updateNodeLinks();
				self.workspace.deselectNodes(list);
				self.workspace.updateArrows();
			});
		});
	};

	this.deleteNode = function(node) {
		const index = self.nodes.indexOf(node);
		if (index >= 0) {
			self.recordNodeAction('removed', node);
			self.nodes.splice(index, 1);
		}
		self.setYarnDocumentIsDirty();
	};

	this.cloneNodeArray = function(nodeArray) {
		return nodeArray.map(function(oldNode) {
			return new Node({
				title: oldNode.title(),
				body: oldNode.body(),
				tags: oldNode.tags(),
				colorID: oldNode.colorID(),
				x: oldNode.x() + 10,
				y: oldNode.y() + 10,
			});
		});
	};

	// TODO: used just once. Fuse with newNodeAt and makeNodeWithName
	this.newNode = function(updateLinks = true) {
		var node = new Node();
		self.nodes.push(node);

		if (updateLinks) self.updateNodeLinks();

		self.recordNodeAction('created', node);

		return node;
	};

	this.newNodeAt = function(x, y) {
		var node = new Node({ x: x - 100, y: y - 100 });

		self.nodes.push(node);

		self.updateNodeLinks();
		self.recordNodeAction('created', node);

		return node;
	};

	this.searchTextInEditor = function(show = true) {
		if (show) {
			self.editor.execCommand('find');
		} else if (self.editor.searchBox) {
			self.editor.searchBox.hide();
		}
	};

	this.editNode = function(node, dontOpenInactive = true) {
		if (!dontOpenInactive && !node.active()) {
			return;
		}

		// this is a setting that can only be set by the VSCode extension
		// if it's true, when we go to edit a node we actually open it in VSCode's text editor
		if (self.settings.alwaysOpenNodesInVisualStudioCodeEditor()) {
			self.editNodeInVisualStudioCodeEditor(node);
			return;
		}

		// Make sure we save the node being currently edited before editing a new
		// one using the context menu
		if (self.editing() && self.editing() !== node) self.saveNode(false);

		if (self.isEditorInPlayMode()) {
			self.togglePlayMode(false);
		}
		if (self.isEditorInPreviewMode) {
			self.togglePreviewMode(false);
		}

		node.oldTitle = node.title(); // To check later if "title" changed

		self.editing(node);

		self.mustUpdateTags = true;

		$('#node-editor-background')
			.css({ opacity: 0 })
			.transition({ opacity: 1 }, 250);
		$('#node-editor')
			.css({ y: '-100', opacity: 0 })
			.transition({ y: '0', opacity: 1.0 }, 250);
		self.editor = ace.edit('editor');
		self.editor.setOptions({
			scrollPastEnd: 0.5,
		});
		self.editor.focus();
		self.editor.navigateFileEnd();

		/// set color picker
		$('#colorPicker').spectrum({
			flat: true,
			showButtons: false,
			showInput: true,
			showPalette: true,
			preferredFormat: 'hex',
			palette: [
				['#000', '#444', '#666', '#999', '#ccc', '#eee', '#f3f3f3', '#fff'],
				['#f00', '#f90', '#ff0', '#0f0', '#0ff', '#00f', '#90f', '#f0f'],
				[
					'#f4cccc',
					'#fce5cd',
					'#fff2cc',
					'#d9ead3',
					'#d0e0e3',
					'#cfe2f3',
					'#d9d2e9',
					'#ead1dc',
				],
			],
			move: function(color) {
				self.applyPickerColorEditor(color);
			},
			clickoutFiresChange: true,
		});

		/// Enable autocompletion for node links
		const langTools = ace.require('ace/ext/language_tools');
		const nodeLinksCompleter = Utils.createAutocompleter(
			['string.llink'],
			app.getOtherNodeTitles().map(title => {
				const otherNode = self.getNodeByTitle(title);
				return {
					word: title,
					title,
					about: `${
						otherNode.tags().length > 0 ? `tags: ${otherNode.tags()}<br/>` : ''
					}Preview:<br/>${otherNode.clippedBody()}`,
					titleStyle: node.titleStyles[otherNode.colorID()],
				};
			}),
			'Node Link'
		);
		// console.log(langTools);
		langTools.setCompleters([
			nodeLinksCompleter,
			langTools.keyWordCompleter,
			langTools.textCompleter,
			langTools.snippetCompleter,
		]);

		// autocompletion
		let autoCompleteTimeout = undefined;
		self.editor.getSession().on('change', function(evt) {
			self.updateEditorStats(true);
			const autoComplete = self.settings.autoCloseTags();
			if (evt.action === 'insert' && autoComplete) {
				if (self.richTextFormatter.justInsertedAutoComplete) {
					self.richTextFormatter.justInsertedAutoComplete = false;
					return;
				}
				autoCompleteTimeout && clearTimeout(autoCompleteTimeout);
				autoCompleteTimeout = setTimeout(() => {
					autoCompleteTimeout = undefined;
					self.richTextFormatter.completableTags.forEach(tag => {
						if (self.getTagBeforeCursor() === tag.Start) {
							self.richTextFormatter.justInsertedAutoComplete = true;
							let insertedText = tag.Completion;
							let offset = tag.Offset;
							if (self.settings.autoCloseBrackets()) {
								if (tag.BehaviorCompletion) {
									insertedText = tag.BehaviorCompletion;
									offset += 1;
								}
							}
							tag.Completion && self.insertTextAtCursor(insertedText);
							tag.Offset && self.moveEditCursor(offset);
							tag.Func && tag.Func();
						}
					});
				}, 200);
			}
		});

		self.toggleInvertColors();
		self.toggleShowCounter();
		self.toggleSpellCheck();
		self.validateTitle(); // warn if title already exists
		self.updateEditorStats();
		self.updateEditorOptions();

		if (
			self.$searchField.val().length > 0 &&
			$('.search-body input').is(':checked')
		) {
			self.editor.findAll(self.$searchField.val());
		}

		if (self.settings.editorSplit()) {
			self.splitEditor();
			self.workspace.warpToNodeByIdx(node.index());
		}

		if (self.settings.editorSplitDirection() === 'right') {
			$('#editor-form').addClass('split-editor-right');
			$('#editor-resize-handle').addClass('float-right');
		} else {
			$('#editor-form').removeClass('split-editor-right');
			$('#editor-resize-handle').removeClass('float-right');
		}

		$('.node-editor').on('pointerdown', '> *', function(e) {
			if (self.isEditorSplit) {
				self.focusEditor(true);
				e.stopPropagation();
			}
		});

		// Remove app-info while editor is open, can't see it anyway
		$('.app-info').hide();

		self.editor.resize();
		if (node.undoManager != null) {
			self.editor.session.setUndoManager(node.undoManager);
		}

		var event = new CustomEvent('yarnEditorOpen');
		event.document = document;
		event.data = data;
		event.app = app;
		window.dispatchEvent(event);
		window.parent.dispatchEvent(event);

		setTimeout(() => {
			/// init spell check
			if (self.settings.spellcheckEnabled()) {
				load_dictionary(self.settings.language().split('-')[0]);
				enable_spellcheck();
			}
		}, 30);
	};

	this.splitEditor = function() {
		self.isEditorSplit = true;
		self.focusEditor(true);
		self.settings.editorSplit(true);

		self.editorResizeHandleOptions.resizeWidthFrom =
			self.settings.editorSplitDirection() === 'right' ? 'left' : 'right';

		// Editor Classes
		$('#editor-form')
			.width(self.settings.editorSplitSize())
			.addClass('split-editor')
			.toggleClass(
				'split-editor-right',
				self.settings.editorSplitDirection() === 'right'
			)
			.resizable(self.editorResizeHandleOptions);

		// Hide editor background
		$('#node-editor-background').addClass('hidden');

		// Lower z-index
		$('#node-editor').css({ 'z-index': 10002 });

		// Show resize handle
		$('#editor-resize-handle')
			.removeClass('hidden')
			.toggleClass(
				'float-right',
				self.settings.editorSplitDirection() === 'right'
			);

		// Reveal/hide buttons
		$('#split-editor-button').addClass('hidden');
		$('#snap-editor-button').removeClass('hidden');
		$('#exit-editor').removeClass('hidden');
		$('#full-size-editor-button').removeClass('hidden');
		$('#split-button-separator').removeClass('hidden');

		self.ui.checkAndMoveAppButtons();
	};

	this.toggleEditorView = function() {
		self.settings.editorSplit(!self.settings.editorSplit());

		self.reopenEditor();
	};

	this.reopenEditor = function() {
		self.saveNode();
		let editingNode = self.editing();
		self.closeEditor();
		setTimeout(() => {
			self.editNode(editingNode);
		}, 250);
	};

	this.editorSnapToggle = function() {
		self.settings.editorSplitDirection(
			self.settings.editorSplitDirection() === 'right' ? 'left' : 'right'
		);

		self.reopenEditor();
	};

	this.focusEditor = function(value) {
		if (value === true) {
			$('#node-editor').removeClass('split-editor-unfocused');
			self.isEditorFocused = true;
		} else {
			$('#node-editor').addClass('split-editor-unfocused');
			self.isEditorFocused = false;
		}
	};

	this.getSplitEditorXOffset = function() {
		let splitEditorXOffset = 0;

		if (self.inEditor() && self.settings.editorSplit()) {
			splitEditorXOffset = $('#editor-form').width() / 2;

			if (self.settings.editorSplitDirection() === 'right') {
				splitEditorXOffset *= -1;
			}
		}

		return splitEditorXOffset;
	};

	// called by the "Edit in Visual Studio Code Text Editor" button
	// this sends a message to the extension telling it to open the node in a text editor
	this.editNodeInVisualStudioCodeEditor = function(node) {
		if (self.usingVisualStudioCodeExtension()) {
			// updating the document is actually a trick to force VSCode to think the open document is
			// dirty so that if it's not "pinned" it won't close when the editor swaps
			self.setYarnDocumentIsDirty();

			// tell VSCode extension to open our node in a new editor
			window.vsCodeApi.postMessage({
				type: 'OpenNode',
				payload: {
					title: node.title().trim(),
					tags: node.tags().trim(),
					body: self.trimBodyLinks(node.body().trim()),
				},
			});
		} else {
			console.error(
				"Tried to open node in Visual Studio Code text editor but we're not in the Visual Studio Code extension"
			);
		}
	};

	this.chooseRelativePathImage = function(imagePath) {
		self.insertTextAtCursor(imagePath);
	};

	this.getNodeByTitle = function(nodeTitle) {
		return self.nodes().find(
			node =>
				node
					.title()
					.trim()
					.toLowerCase() === nodeTitle.trim().toLowerCase()
		);
	};
	this.openNodeByTitle = function(nodeTitle, findText = '') {
		if (self.isEditorInPlayMode()) self.togglePlayMode();

		self.makeNodeWithName(nodeTitle);
		const nodeWithTitle = self.nodes()
			.find(node => node
				.title()
				.trim()
				.toLowerCase() === nodeTitle.trim().toLowerCase());

		if (nodeWithTitle) {
			self.editNode(nodeWithTitle, true);
			if (findText) setTimeout(() => self.editor.find(findText), 200);
		}
	};

	this.openLastEditedNode = function() {
		if (self.nodeVisitHistory.length === 0) {
			self.saveNode();
			self.closeEditor();
		} else {
			const title = self.nodeVisitHistory.pop();
			self.propagateUpdateFromNode(self.editing());
			self.openNodeByTitle(title);
		}
	};

	this.getSpellCheckSuggestionItems = function() {
		var wordSuggestions = suggest_word_for_misspelled(
			self.editor.getSelectedText()
		);
		if (wordSuggestions) {
			var suggestionObject = {};
			wordSuggestions.forEach(suggestion => {
				suggestionObject[suggestion] = {
					name: suggestion,
					icon: 'edit',
					callback: key => {
						self.insertTextAtCursor(key);
					},
				};
			});
			return suggestionObject;
		} else {
			return false;
		}
	};

	this.getThesaurusItems = function() {
		var synonyms = require('synonyms');
		var words = synonyms(self.editor.getSelectedText());
		if (!words) return false;
		var wordSuggestions = [];
		Object.keys(words).forEach(function(type) {
			words[type].forEach(function(word) {
				if (!wordSuggestions.includes(word) && word !== type) {
					wordSuggestions.push(word);
				}
			});
		});
		if (wordSuggestions.length > 0) {
			var suggestionObject = {};
			wordSuggestions.forEach(suggestion => {
				suggestionObject[suggestion] = {
					name: suggestion,
					icon: 'edit',
					callback: key => {
						self.insertTextAtCursor(key);
					},
				};
			});
			return suggestionObject;
		} else {
			return false;
		}
	};

	this.toggleSpellCheck = function() {
		// Timeout so spellcheck can toggle after the spelling check settings are updated
		setTimeout(function() {
			if (self.settings.spellcheckEnabled()) {
				load_dictionary(self.settings.language().split('-')[0]);
				enable_spellcheck();
			}
			else disable_spellcheck();
		}, 50);
	};

	this.toggleInvertColors = function() {
		const cssOverwrite = self.settings.invertColorsEnabled()
			? { filter: 'invert(100%)' }
			: { filter: 'invert(0%)' };

		$('#app').css(cssOverwrite);
		$('#app-bg').css(cssOverwrite);
		$('.tooltip').css(cssOverwrite);
		$('.node .body').css(cssOverwrite);
		$('.editor-container .editor-preview').css(cssOverwrite);
	};

	this.initGrid = function() {
		if (self.settings.snapGridEnabled()) {
			var width = $(window).width();
			var height = $(window).height();
			$('#grid-canvas').attr('width', width);
			$('#grid-canvas').attr('height', height);
			$('#gridSize').attr('disabled', false);
			self.workspace.gridContext.strokeStyle = self.workspace.gridContext.fillStyle = $(
				'.grid-canvas'
			).css('color');
		} else {
			$('#gridSize').attr('disabled', true);
		}

		app.workspace.updateGrid();
	};

	this.toggleShowCounter = function() {
		if (self.settings.editorStatsEnabled()) {
			$('.node-editor .form .editor-counter').css({
				display: 'initial',
			});
		} else {
			$('.node-editor .form .editor-counter').css({
				display: 'none',
			});
		}
	};

	this.toggleAutocompleteSuggestions = function() {
		self.settings.autocompleteSuggestionsEnabled(
			!self.settings.autocompleteSuggestionsEnabled()
		);
		self.updateEditorOptions();
	};

	this.toggleAutoCloseBrackets = function() {
		self.settings.autoCloseBrackets(!self.settings.autoCloseBrackets());
		self.updateEditorOptions();
	};

	this.updateEditorOptions = function() {
		self.editor.setOptions({
			enableBasicAutocompletion: app.settings.autocompleteSuggestionsEnabled(),
			enableLiveAutocompletion: app.settings.autocompleteSuggestionsEnabled(),
			behavioursEnabled: app.settings.autoCloseBrackets(),
		});
	};

	// TODO: move to UI?
	this.togglePreviewMode = function(previewModeOverwrite) {
		const editor = $('.editor')[0];
		const editorPreviewer = $('#editor-preview')[0];

		self.isEditorInPreviewMode = previewModeOverwrite;
		app.ui.dispatchEvent('yarnInPreviewMode');
		if (previewModeOverwrite) {
			$('.bbcode-toolbar').addClass('hidden');
			//preview mode
			editor.style.display = 'none';
			editorPreviewer.style.display = 'block';
			editorPreviewer.innerHTML = self.richTextFormatter.richTextToHtml(
				self.editing().body(),
				true
			);
			editorPreviewer.scrollTop = self.editor.renderer.scrollTop;
		} else {
			$('.bbcode-toolbar').removeClass('hidden');
			self.editor.session.setScrollTop(editorPreviewer.scrollTop);
			editorPreviewer.innerHTML = '';
			editorPreviewer.style.display = 'none';
			editor.style.display = 'flex';
			self.editor.focus();
			self.editor.resize();
			//close any pop up helpers tooltip class
			if ($('#colorPicker-container').is(':visible')) {
				$('#colorPicker-container').hide();
			}
		}
	};

	// TODO: move to editor class
	this.appendText = function(textToAppend) {
		self.editing().body(self.editing().body() + textToAppend);
		// scroll to end of line
		const row = self.editor.session.getLength() - 1;
		const column = self.editor.session.getLine(row).length;
		self.editor.gotoLine(row + 1, column);
	};

	// TODO: move to editor class
	this.moveEditCursor = function(offset) {
		const position = self.editor.getCursorPosition();
		self.editor.gotoLine(position.row + 1, position.column + offset);
		self.editor.focus();
	};

	// TODO: move to editor class
	this.insertTextAtCursor = function(textToInsert, scrollToLine = false) {
		if (!self.editing()) return;
		self.editor.session.replace(self.editor.selection.getRange(), '');
		const position = self.editor.getCursorPosition();
		self.editor.session.insert(position, textToInsert);

		if (scrollToLine) {
			const newPosition = self.editor.getCursorPosition();
			editor.scrollToLine(newPosition.row, true, true, function() {});
			self.editor.focus();
		}
		self.updateEditorStats(true);
		self.editor.focus();
	};

	// TODO: move to editor class
	this.getTagBeforeCursor = function() {
		const selectionRange = self.editor.getSelectionRange();
		const curLine = selectionRange.start.row;
		const curLineText = self.editor.session.getLine(curLine);

		const textBeforeCursor = curLineText.substring(
			selectionRange.start.column - 2,
			selectionRange.start.column + 2
		);
		if (!textBeforeCursor) {
			return '';
		}
		if (textBeforeCursor.startsWith('<<') && !textBeforeCursor.endsWith('>>')) {
			if (selectionRange.start.column > 1) {
				return '<<';
			}
		}
		return self.richTextFormatter.identifyTag(textBeforeCursor);
	};

	this.saveNode = function() {
		const node = self.editing();
		if (node) {
			const editorTitleElement = $('#editorTitle')[0];

			// Ensure the title is unique
			const title = self.getFutureEditedNodeTitle();

			// Update the title in the UI
			editorTitleElement.value = title;
			node.title(title);

			// Remove leading and trailing spaces from the body links
			node.body(self.trimBodyLinks(node.body().trim()));

			self.makeNewNodesFromLinks();
			self.propagateUpdateFromNode(node);
			self.workspace.updateArrows();

			setTimeout(self.updateSearch, 600);

			self.setYarnDocumentIsDirty();
			app.ui.dispatchEvent('yarnSavedNode');
		}
	};

	this.closeEditor = function() {
		self.editing().undoManager = self.editor.session.getUndoManager();
		$('#node-editor-background').transition({ opacity: 0 }, 250);
		$('#node-editor').transition({ y: '-100', opacity: 0 }, 250, function(e) {
			self.editing(null);
		});

		self.isEditorSplit = false;
		self.isEditorFocused = false;

		$('.app-info').show();

		app.ui.resetAppButtonsLocation();
		if (self.isEditorInPlayMode()) {
			self.togglePlayMode(false);
		}
		if (self.isEditorInPreviewMode) {
			self.togglePreviewMode(false);
		}
	};

	this.convertMarkup = function() {
		self.nodes().forEach(node => {
			node.body(self.richTextFormatter.convert(node.body()));
		});
	};

	this.updateSearch = function() {
		var on = 1;
		var off = 0.25;

		for (var i = 0; i < self.nodes().length; i++) {
			var node = self.nodes()[i];
			var element = $(node.element);
			var searchText = app.$searchField.val().toLowerCase();
			const {
				matchTitle,
				matchBody,
				matchTags,
				clearSearch,
			} = app.ui.nodeSearchMatches(node, searchText);
			if (!clearSearch) {
				if (matchTitle || matchBody || matchTags) {
					node.active({ title: matchTitle, body: matchBody, tags: matchTags });
					element.clearQueue();
					element.transition({ opacity: on }, 500);
				} else {
					node.active(false);
					element.clearQueue();
					element.transition({ opacity: off }, 500);
				}
			} else {
				node.active(true);
				element.clearQueue();
				element.transition({ opacity: on }, 500);
			}
		}
	};

	this.trimBodyLinks = function(body) {
		var re = /\<\<\s*jump\s+([a-zA-Z0-9_]+?)\s*\>\>/g;
		return body.replace(re, '<<jump $1>>');
	};

	this.updateNodeLinks = function() {
		for (let node of self.nodes()) {
			node.updateLinks();
		}
	};

	// TODO: probably 'propagateUpdateFromNode' can be used as a
	// replacement for 'updateNodeLinks'. I'll check it in next iterations.
	this.propagateUpdateFromNode = function(node) {
		var toUpdate = [];
		var updated = [];

		toUpdate.push(node);

		while ((node = toUpdate.pop())) {
			if (updated.includes(node)) {
				continue;
			}

			updated.push(node);

			node.updateLinks();

			node.linkedTo().forEach(child => {
				if (!updated.includes(child)) {
					toUpdate.push(child);
				}
			});

			node.linkedFrom().forEach(parent => {
				if (!updated.includes(parent)) {
					toUpdate.push(parent);
				}
			});
		}
	};

	this.updateTagsRepository = function() {
		if (!app.mustUpdateTags) return;

		app.mustUpdateTags = false;

		const findFirstFreeId = () => {
			const usedIds = self.tags().map(tag => tag.id);
			for (let id = 1; ; ++id) if (!usedIds.includes(id)) return id;
		};

		// Reset count
		self.tags().forEach(tag => (tag.count = 0));

		// Recount tags and add new
		self.nodes().forEach(node => {
			Utils.uniqueSplit(node.tags(), ' ').forEach(tag => {
				const found = self.tags().find(e => e.text == tag);
				if (found) {
					++found.count;
				} else {
					const id = findFirstFreeId();
					self.tags.push({
						id: id,
						style: 'tag-style-' + id,
						text: tag,
						count: 1,
					});
				}
			});
		});

		// Remove unused tags
		let i = self.tags().length;
		while (i--) {
			if (self.tags()[i].count === 0) self.tags().splice(i, 1);
		}
	};

	this.makeNewNodesFromLinks = function() {
		if (!self.settings.createNodesEnabled())
			return console.info('Autocreate new nodes disabled');

		var nodeLinks = self.editing().getLinksInNode();
		if (nodeLinks == undefined) {
			return;
		}
		for (var i = 0; i < nodeLinks.length; i++) {
			// Create new Nodes from Node Links
			const newNodeName = nodeLinks[i].trim();
			var newNodeOffset = 220 * (i + 1);
			self.makeNodeWithName(newNodeName, newNodeOffset);
		}
	};

	this.makeNodeWithName = function(newNodeName, newNodeOffset = 220) {
		const otherNodeTitles = self.getOtherNodeTitles();
		if (
			app.settings.documentType() === 'ink'
			//	 &&
			// ['END', '->'].includes(newNodeName)
		)
			return;
		if (
			newNodeName &&
			newNodeName.length > 0 &&
			!otherNodeTitles.includes(newNodeName) &&
			newNodeName != self.editing().title()
		) {
			self
				.newNodeAt(self.editing().x() + newNodeOffset, self.editing().y() - 120)
				.title(newNodeName);
		}
	};

	this.titleExistsTwice = function(title) {
		return (
			self.nodes().filter(node => node.title().trim() === title.trim()).length >
			1
		);
	};

	this.getFutureEditedNodeTitle = function() {
		// Ensure the title is unique
		const editorTitleElement = $('#editorTitle')[0];
		// Return the title that will be used when changes are applied
		return self.getUniqueTitle(editorTitleElement.value.trim());
	};

	this.getOtherNodeTitles = function() {
		const result = [];
		self.nodes().forEach(node => {
			if (!self.editing() || node.title() !== self.editing().title()) {
				if (
					app.settings.documentType() === 'ink' &&
					node.title().trim() === data.InkGlobalScopeNodeName
				)
					return;
				result.push(node.title().trim());
			}
		});
		return result;
	};

	this.getHighlightedText = function(text) {
		text = text.replace(/\</g, '&lt;');
		text = text.replace(/\>/g, '&gt;');
		text = text.replace(
			/\&lt;\&lt;(.*?)\&gt;\&gt;/g,
			'<p class="conditionbounds">&lt;&lt;</p><p class="condition">$1</p><p class="conditionbounds">&gt;&gt;</p>'
		);
		text = text.replace(
			/[^:]\/\/(.*)?($|\n)/g,
			'<span class="comment">//$1</span>\n'
		);
		text = text.replace(
			/\/\*((.|[\r\n])*)?\*\//gm,
			'<span class="comment">/*$1*/</span>'
		);
		text = text.replace(
			/\/\%((.|[\r\n])*)?\%\//gm,
			'<span class="comment">/%$1%/</span>'
		);

		// create a temporary document and remove all styles inside comments
		var div = $('<div>');
		div[0].innerHTML = text;
		div.find('.comment').each(function() {
			$(this)
				.find('p')
				.each(function() {
					$(this).removeClass();
				});
		});

		// unhighlight links that don't exist
		div.find('.linkname').each(function() {
			var name = $(this).text();
			var found = false;
			for (var i in self.nodes()) {
				if (
					self
						.nodes()
						[i].title()
						.toLowerCase() == name.toLowerCase()
				) {
					found = true;
					break;
				}
			}
			if (!found) $(this).removeClass('linkname');
		});

		text = div[0].innerHTML;
		return text;
	};

	this.updateLineNumbers = function(text) {
		// update line numbers
		var lines = text.split('\n');
		var lineNumbers = '';
		for (var i = 0; i < Math.max(1, lines.length); i++) {
			if (i == 0 || i < lines.length - 1 || lines[i].length > 0)
				lineNumbers += i + 1 + '<br />';
		}
		$('.editor-container .lines').html(lineNumbers);
	};

	this.moveNodes = function(offX, offY) {
		for (var i in self.nodes()) {
			var node = self.nodes()[i];
			node.moveTo(node.x() + offX, node.y() + offY);
		}
	};

	this.getFirstFoundNode = function(search) {
		return self.nodes().find(
			node =>
				node
					.title()
					.toLowerCase()
					.trim() === search
		);
	};

	this.searchWarp = function() {
		// if search field is empty
		var search = self.$searchField
			.val()
			.toLowerCase()
			.trim();

		if (search === '') {
			// warp to the first node
			self.workspace.warpToNodeByIdx(0);
		} else {
			const foundNode = self.getFirstFoundNode(search);
			self.workspace.warpToNodeByIdx(self.nodes.indexOf(foundNode));
		}
	};

	this.clearSearch = function() {
		self.$searchField.val('');
		self.updateSearch();
	};

	this.updateEditorStats = function(writeCurrent = false) {
		var text = self.editor.getSession().getValue();
		var cursor = self.editor.getCursorPosition();

		var lines = text.split('\n');
		$('.editor-counter .character-count').html(text.length);
		$('.editor-counter .line-count').html(lines.length);
		$('.editor-counter .row-index').html(cursor.row);
		$('.editor-counter .column-index').html(cursor.column);
		data.saveAppStateToLocalStorage(writeCurrent);
	};

	this.getUniqueTitle = function(desiredTitle) {
		var currentlyUsedTitles = self.getOtherNodeTitles();
		if (desiredTitle && !currentlyUsedTitles.includes(desiredTitle)) {
			return desiredTitle;
		}

		var baseTitle = desiredTitle || 'Node';
		var counter = 2;

		// If the title ends with "_[number]" use the same prefix with next number
		const re = /^(.*)(_([0-9]+))$/;
		const matches = baseTitle.match(re);
		if (matches && matches.length === 4) {
			baseTitle = matches[1];
			counter = Number(matches[3]);
		}

		if (!currentlyUsedTitles.includes(baseTitle)) {
			return baseTitle;
		}

		for (; ; ++counter) {
			var newTitle = baseTitle + '_' + counter;
			if (!currentlyUsedTitles.includes(newTitle)) {
				return newTitle;
			}
		}
	};
	this.navigateToNodeDuringPlayTest = function(nodeName, find = '') {
		self.openNodeByTitle(nodeName, find);
		self.togglePlayMode(true);
		// if (app.isEditorSplit) {
		app.workspace.warpToNode(app.getFirstFoundNode(nodeName));
		// }
	};
};
